"""OSCI gating reporter."""

import argparse
import base64
import gzip
import itertools
import os
import re
import typing
import xml.etree.ElementTree as ET

from cki_lib import logger
from cki_lib import messagequeue
from cki_lib import metrics
from cki_lib import misc
from cki_lib import session
from cki_lib import stomp
from cki_lib import yaml
from cki_lib.kcidb import checks
import datawarehouse
import sentry_sdk

from cki.kcidb.utils import unknown_issues

LOGGER = logger.get_logger('cki_tools.gating_reporter')
SESSION = session.get_session('cki_tools.gating_reporter')

SUPPORTED_KOJI_INSTANCES = yaml.load(contents=os.environ.get('SUPPORTED_KOJI_INSTANCES', '[]'))


def compress_and_b64encode(data: str) -> str:
    """Compress and perform base64 encoding on the input data."""
    encoded_data = data.encode('utf-8')
    return base64.b64encode(gzip.compress(encoded_data)).decode('utf-8')


def _dict_to_xml(dictionary: typing.Mapping[str, typing.Any], parent: ET.Element) -> None:
    for key, value in dictionary.items():
        if isinstance(value, dict):
            element = ET.Element(key)
            parent.append(element)
            _dict_to_xml(value, element)
        elif isinstance(value, list):
            element = ET.Element(key)
            parent.append(element)
            for item in value:
                _dict_to_xml(item, element)
        elif key.startswith("@"):
            parent.set(key[1:], str(value))
        else:
            element = ET.SubElement(parent, key)
            element.text = str(value)


def dict_to_xml(dictionary: typing.Mapping[str, typing.Any]) -> str:
    """
    Convert a dictionary representation of xunit into its corresponding XML format.

    Args:
        dictionary (dict): The dictionary representing xunit data.

    Returns:
        str: The XML string representation of the xunit data.
    """
    # The dummy tags are used to construct the XML structure and are removed at the end.
    root = ET.Element('dummy_root')
    _dict_to_xml(dictionary, root)
    # Create a string buffer to hold the XML content
    xml_string = ET.tostring(root, encoding='utf-8', method='xml')
    # Insert the XML declaration at the beginning of the string
    xml_declaration = b'<?xml version="1.0" encoding="utf-8"?>\n'
    xml_string_with_declaration = xml_declaration + xml_string
    # Remove dummy tags from the result
    return str(re.sub(r'</?dummy_(root|testsuites|testcases)\s?/?>',
                      '',
                      xml_string_with_declaration.decode())
               )


def _make_xunit_dict(test):
    subtests = test.misc.get("results", [])
    osci_status = _status_to_osci_dashboard_format(test.status)
    testcases = (
        # if there are no subtests, a failed test should still have at least one "failure"
        [{"testcase": {"@name": test.comment, "@result": osci_status, "failure": {}}}]
        if test.status == "FAIL" and not subtests
        else []
    )
    xunit_dict = {
        "testsuite": {
            "@name": test.comment,
            "@result": osci_status,
            "@tests": str(len(subtests)),
            "properties": {
                "property": {
                    "@name": "baseosci.result",
                    "@value": osci_status,
                }
            },
            "dummy_testcases": testcases,  # filled in the subtests loop
        }
    }
    for subtest in subtests:
        testcase = {
            "testcase": {
                "@name": subtest.comment,
                "@result": _status_to_osci_dashboard_format(subtest.status),
                "logs": [
                    {"log": {"@href": output_file["url"], "@name": output_file["name"]}}
                    for output_file in (subtest.output_files or [])
                ],
            }
        }
        if subtest.status == "FAIL":
            testcase["testcase"]["failure"] = {}
        testcases.append(testcase)
    return xunit_dict


def _status_to_osci_dashboard_format(status: str) -> str:
    """Convert status to the format understood by the OSCI dashboard."""
    if status == 'FAIL':
        return 'failed'
    return 'passed'


def make_xunit_dict(tests, status):
    """
    Generate an xunit dictionary representation for a list of KCIDBTest objects.

    Args:
        tests (list): A list of KCIDBTest objects representing various test results.
        status: overall KCIDB status

    Returns:
        dict: The xunit dictionary representation containing the test results.

    """
    overall_result = _status_to_osci_dashboard_format(status)
    results = [_make_xunit_dict(test) for test in tests]

    xunit_dict = {
        'testsuites': {
            '@overall-result': overall_result,
            'properties': {
                'property': {
                    '@name': 'baseosci.overall-result',
                    '@value': overall_result
                }
            },
            'dummy_testsuites': results
        }
    }
    return xunit_dict


def make_xunit_string(tests, status):
    """
    Generate an xunit XML string representation for a list of KCIDBTest objects.

    Args:
        tests (list): A list of KCIDBTest objects representing various test results.
        status: overall KCIDB status

    Returns:
        str: The XML string representation of the xunit data.

    """
    if not tests:
        return None
    try:
        xunit_dict = make_xunit_dict(tests, status)
        xunit_xml_data = dict_to_xml(xunit_dict)
        return compress_and_b64encode(xunit_xml_data)
    except Exception:  # pylint: disable=broad-except
        LOGGER.exception("An error occurred while generating the xunit string")
        return None


def dw_checkout(checkout_id):
    """Return a DW checkout."""
    return datawarehouse.Datawarehouse(
        os.environ['DATAWAREHOUSE_URL'],
        token=os.environ['DATAWAREHOUSE_TOKEN_GATING_REPORTER'],
        session=SESSION).kcidb.checkouts.get(id=checkout_id).all.get()


def koji_instance(checkout):
    """Return the configuration of a matching Koji instance or None."""
    if checkout.misc.get('brew_task_id'):
        for instance in SUPPORTED_KOJI_INSTANCES:
            for provenance in checkout.misc.get('provenance', []):
                if provenance['url'].startswith(instance['web_url']):
                    return instance
    return None


def _compute_results(dw_all, build_ids: set) -> dict:
    """Compute the summarized outcome of the tests in the given builds, plus "xunit" or "note".

    Args:
        dw_all: Instance of the response from the "checkout-all" endpoint.
        build_ids: IDs from builds to filter tests by

    Returns:
        If there's at least one "FAIL" currently untriaged, or triaged as a regression,
        or alternatively, one of the given builds didn't boot at least once:
        {"result": "failed", "xunit": <base64 encoded XML report about failures>}

        Else, if there's at least on "ERROR" currently untriaged, or triaged as a regression:
        {"result": "info", "note": "test aborted, ignoring the result..."}

        Otherwise:
        {"result": "passed"}
    """
    arch_tests = [t for t in dw_all.tests if t.build_id in build_ids]

    problematic_tests = unknown_issues(dw_all, tests=arch_tests)
    if failures := (
        {t for t in problematic_tests if t.status == "FAIL"}
        # If all boots from at least one build didn't complete, consider it a fail
        | checks.broken_boot_tests(dw_all=dw_all, tests=arch_tests)
    ):
        test_result = {"result": "failed", "xunit": make_xunit_string(failures, "FAIL")}
    # don't gate errors (https://gitlab.com/cki-project/umb-messenger/-/issues/31)
    elif any(t.status == "ERROR" for t in problematic_tests):
        test_result = {"result": "info", "note": "test aborted, ignoring the result..."}
    else:
        test_result = {"result": "passed"}
    return test_result


def report(message_type, checkout_id):
    """Report via an OSCI gating message for a checkout."""
    LOGGER.info('Gathering data for %s: %s', message_type, checkout_id)

    dw_all = dw_checkout(checkout_id)
    checkout = dw_all.checkouts[0]
    if not (instance := koji_instance(checkout)):
        LOGGER.debug('  ignoring, not a Brew build')
        return

    try:
        source_package_nvr = "-".join(
            checkout.misc[f]
            for f in ("source_package_name", "source_package_version", "source_package_release")
        )
    except KeyError as e:
        LOGGER.warning("  ignoring, missing part of NVR: %r", e.args[0])
        return

    # groupby requires sorted data based on the key
    sorted_builds = sorted(dw_all.builds, key=lambda b: b.architecture)
    for architecture, grouped_builds in itertools.groupby(sorted_builds,
                                                          key=lambda b: b.architecture):
        arch_builds = list(grouped_builds)  # Cast iterator to list to iter multiple times

        message = {
            'contact': {
                'name': 'CKI (Continuous Kernel Integration)',
                'team': 'CKI',
                'docs': 'https://cki-project.org',
                'url': 'https://gitlab.com/cki-project',
                'slack': 'https://redhat.enterprise.slack.com/archives/C04KPCFGDTN',
                'email': 'cki-project@redhat.com',
                'environment': 'prod',
            },
            'run': {
                'url': f'{checkout.manager.api.host}/kcidb/checkouts/{checkout.misc["iid"]}',
                'log': f'{checkout.manager.api.host}/kcidb/checkouts/{checkout.misc["iid"]}',
            },
            'artifact': {
                'type': instance['artifact_type'],
                'id': checkout.misc.get('brew_task_id'),
                'issuer': checkout.attributes.get('contacts', ['CKI'])[0],
                'component': checkout.misc['source_package_name'],
                'variant': None,
                'nvr': source_package_nvr,
                'scratch': checkout.misc.get('scratch', True),
            },
            'system': [{
                'os': checkout.tree_name,
                'provider': 'beaker',
                'architecture': architecture,
            }],
            'pipeline': {
                'id': f'{checkout.id}-{architecture}',
                'name': 'cki-gating',
            },
            'test': {
                'type': f'tier1-{architecture}',
                'category': 'functional',
                'namespace': 'cki',
                'docs': 'https://cki-project.org',
                'xunit': '',
            },
            # https://issues.redhat.com/browse/OSCI-2348 😢
            'generated_at': misc.now_tz_utc().isoformat()[:19] + 'Z',
            'version': '1.1.14',
        }

        # Only add test results to complete test runs. We cannot rely on empty
        # test lists because of possible delays or super quick results!
        if message_type == 'complete':
            message["test"].update(_compute_results(dw_all, build_ids={b.id for b in arch_builds}))

        topic = f'/topic/VirtualTopic.eng.ci.cki.{instance["topic"]}.test.{message_type}'

        if misc.is_production():
            LOGGER.info('Sending message to %s', topic)
            stomp.StompClient().send_message(message, topic)
        else:
            LOGGER.info('Production mode would send %s to %s', message, topic)


def process_message(body=None, **_):
    """Filter and process messages if requested."""
    object_type = body['object_type']
    object_id = misc.get_nested_key(body, 'object/id')

    LOGGER.info('Processing message for %s %s', object_type, object_id)

    if object_type != 'checkout':
        LOGGER.debug('  ignoring, unsupported object type: %s', object_type)
        return

    if misc.get_nested_key(body, 'object/misc/retrigger', False):
        LOGGER.debug('  ignoring, retriggered checkout')
        return

    match body['status']:
        case 'build_setups_finished':
            report('running', object_id)
        case 'ready_to_report' | 'checkout_issueoccurrences_changed':
            report('complete', object_id)
        case status:
            LOGGER.debug('  ignoring, unsupported message status: %s', status)


def main(argv: typing.Optional[typing.List[str]] = None) -> None:
    """Run main loop."""
    parser = argparse.ArgumentParser(description='Send UMB messages for CKI results')
    parser.add_argument('--message-type', choices=['running', 'complete'],
                        help='Message type to send')
    parser.add_argument('--checkout-id', help='Checkout ID to report')
    args = parser.parse_args(argv)

    if args.message_type and args.checkout_id:
        report(args.message_type, args.checkout_id)
        return

    metrics.prometheus_init()

    messagequeue.MessageQueue().consume_messages(
        os.environ.get('WEBHOOK_RECEIVER_EXCHANGE', 'cki.exchange.webhooks'),
        os.environ['GATING_REPORTER_ROUTING_KEYS'].split(),
        process_message,
        queue_name=os.environ.get('GATING_REPORTER_QUEUE'),
    )


if __name__ == '__main__':
    misc.sentry_init(sentry_sdk)
    main()
